切出來的音訊播放起來像花栗鼠(聲音變尖、變快)
今天就來拆解這些「音訊變形」與「PCM 標頭缺失」的問題,並附上完整解法。
災情描述:將前端切片下來的 PCM 音訊傳給後端或播放器時,聲音變得極高昂且語速變快兩倍,或是反過來變得極低沉粗糙。
根本原因:採樣率與通道數
音訊資料本質上只是一串位元組(Bytes)。如果錄音時用的是 16,000 Hz,但後端或播放器預設用 32,000 Hz 或 44,100 Hz 去解碼,播放器就會在半秒內把一秒的資料播完,導致「花栗鼠效應」。
通道數同樣會出錯:
在 AudioRecorderService 中,訂定統一生態系中的音訊參數
// 務必與 Whisper / Gemini / 後端 API 要求的規格 100% 對齊
const config = RecordConfig(
encoder: AudioEncoder.pcm16bits, // 16-bit PCM 格式
sampleRate: 16000, // 16kHz 採樣率(Whisper 最愛的規格)
numChannels: 1, // Mono 單聲道
);
災情描述:音訊聽得懂,但每當 Chunk 切片交界處,就會發出「喀嗒(Click / Pop)」的爆音,或連續播放時聽起來像機器人斷斷續續。
根本原因:非 Zero-Crossing 切割與 Endianness 錯亂
在 lib/core/utils/pcm_audio_utils.dart 建立工具類別,確保所有音訊 Chunk 都強制 2-Byte(16-bit):
import 'dart:typed_data';
class PcmAudioUtils {
/// 確保 PCM 16-bit 資料長度必為偶數 (Byte Alignment)
static Uint8List alignPcm16Bit(Uint8List chunk) {
if (chunk.length % 2 != 0) {
// 若長度為奇數,截掉最後一個無效 Byte,防止 16-bit 採樣點被剖半錯位
return chunk.sublist(0, chunk.length - 1);
}
return chunk;
}
/// 檢查 PCM 是否全為靜音 (Silence Detection)
/// 防止發送過多的靜音 Chunk 浪費 API 費用與網路頻寬
static bool isSilent(Uint8List chunk, {int threshold = 500}) {
final bytes = alignPcm16Bit(chunk);
final buffer = bytes.buffer.asByteData(bytes.offsetInBytes, bytes.lengthInBytes);
int maxAmplitude = 0;
for (int i = 0; i < bytes.length; i += 2) {
// 以 Little-Endian 讀取 16-bit 有符號整數 (Int16)
int sample = buffer.getInt16(i, Endian.little).abs();
if (sample > maxAmplitude) {
maxAmplitude = sample;
}
}
// 若最大振幅低於閥值,判定為靜音
return maxAmplitude < threshold;
}
}
災情描述:把從 record 套件拿到的原始位元組(Raw PCM Chunk)直接轉檔存成 .wav 發給 OpenAI Whisper REST API,結果 API 報錯退件。
根本原因:Raw PCM 缺少 44-Byte 的 WAV Header
Raw PCM 是「純音訊數據」,沒有標頭檔。一般播放器或 HTTP API 拿到這串 Byte 時,根本不知道它的採樣率是 16k 還是 44.1k,也不知道是單聲道還是雙聲道。必須為這段 PCM 數據加上標準的 44 位元組 WAV Header,它才是一個合法的 .wav 檔案。
在 lib/core/utils/wav_header_builder.dart 實作標頭補全器:
import 'dart:typed_data';
class WavHeaderBuilder {
/// 為原始 PCM 數據前綴加上標準的 44-Byte WAV Header
static Uint8List addWavHeader(
Uint8List pcmData, {
int sampleRate = 16000,
int numChannels = 1,
int bitsPerSample = 16,
}) {
final int byteRate = sampleRate * numChannels * (bitsPerSample ~/ 8);
final int blockAlign = numChannels * (bitsPerSample ~/ 8);
final int dataSize = pcmData.length;
final int chunkSize = 36 + dataSize;
final builder = BytesBuilder();
// 1. RIFF Header
builder.add([0x52, 0x49, 0x46, 0x46]); // "RIFF"
builder.add(_int32ToBytes(chunkSize));
builder.add([0x57, 0x41, 0x56, 0x45]); // "WAVE"
// 2. fmt Sub-chunk
builder.add([0x66, 0x6D, 0x74, 0x20]); // "fmt "
builder.add(_int32ToBytes(16)); // Subchunk1Size (16 for PCM)
builder.add(_int16ToBytes(1)); // AudioFormat (1 for PCM)
builder.add(_int16ToBytes(numChannels));
builder.add(_int32ToBytes(sampleRate));
builder.add(_int32ToBytes(byteRate));
builder.add(_int16ToBytes(blockAlign));
builder.add(_int16ToBytes(bitsPerSample));
// 3. data Sub-chunk
builder.add([0x64, 0x61, 0x74, 0x61]); // "data"
builder.add(_int32ToBytes(dataSize));
// 4. 音訊原始數據
builder.add(pcmData);
return builder.toBytes();
}
static Uint8List _int16ToBytes(int value) {
return Uint8List(2)..buffer.asByteData().setInt16(0, value, Endian.little);
}
static Uint8List _int32ToBytes(int value) {
return Uint8List(4)..buffer.asByteData().setInt32(0, value, Endian.little);
}
}
| 排查項目 | 檢查標準 | 踩坑預防效果 |
|---|---|---|
| 1. 採樣率對齊 | 全專案固定使用 16,000 Hz Mono | 解決花栗鼠 / 低沉變音 |
| 2. 偶數 Byte 對齊 | 送出 Chunk 前呼叫 alignPcm16Bit() | 消除爆音與滋滋雜音 |
| 3. WAV Header | 上傳 REST API 前先添加 44-Byte 檔頭 | 避免 API 格式退件 |
| 4. 靜音過濾(VAD) | 傳送前過濾無聲 Chunk | 省下 30% 以上 API 費用 |
小結
今天整理了語音串流開發中最常見的三大音訊變形陷阱,並提供對應的解法,明天我們將繼續深入即時語音串流的其他實戰,敬請期待!